iT邦幫忙

2024 iThome 鐵人賽

DAY 21
1

簡介

特徵(trait)是用來定義特定型別與其他型別共享的功能,也指定了這些型別要滿足的功能要有哪些,如上一篇提到當需要限縮泛型的型別的時候就很重要。
特徵界限(trait bounds)可以指定泛型型別要是擁有特定行為的任意型別,透過這個方式就可以在我們定義的範圍內又保留彈性。

官方說 Rust 的特徵和其他語言的 interface 類似,不過我自己沒怎麼接觸過,有興趣的人可以比較看看,這裡就不花篇幅比較。

定義特徵

Rust 用關鍵字 trait 定義特徵,和 struct 滿類似,一樣宣告一個英文大寫開頭的名稱,一樣用大括號在裡面定義函數,不過這邊的函數我們可以只定義簽名就好,也就是說,我們可以只定義這個函數的名稱還有輸入輸出的型別,要實作這個特徵的型別自己再去實作實際的邏輯就好。
這些函數定義結尾用 ;分隔。

trait Shape {
	fn new() -> Self;
    fn area(&self) -> f64;
    fn perimeter(&self) -> f64;
}

這邊的self一樣是指實體本身,函數可以是關聯函數或是方法。

實作特徵

我們用 impl A for B 來為某個型別 B 實作 A 特徵,型別沒有限制是 struct ,其他型別也可以,只是實作特徵時有一個限制:該特徵或該型別位於我們的crate時,才能對型別實作特徵。
這部分等到介紹到 crate 、套件、模組時再一起介紹,現階段只需要知道我們目前同一個檔案,如果是自訂型別實作特徵都不會被限制。

我們試著定義不同形狀並實作 Shape 特徵:

trait Shape {
    fn new() -> Self;
    
    fn area(&self) -> f64;
    
    fn perimeter(&self) -> f64;
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Shape for Rectangle {
    fn new() -> Self {
        Self { width: 1.0, height: 1.0 }
    }
    
    fn area(&self) -> f64 {
        self.width * self.height
    }
    
    fn perimeter(&self) -> f64 {
        2.0 * (self.width + self.height)
    }
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn new() -> Self {
        Self { radius: 1.0 }
    }
    
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }

    fn perimeter(&self) -> f64 {
        2.0 * std::f64::consts::PI * self.radius
    }
}

fn main() {
    let rect = Rectangle::new();
    let circle = Circle::new();
    
    println!("矩形面積: {}, 周長: {}", rect.area(), rect.perimeter());
    println!("圓形面積: {}, 周長: {}", circle.area(), circle.perimeter());
}

實作特徵就要實作所有該特徵內的函數,不然編譯器會報錯提醒,例如把 new 的部分移除。

error[E0046]: not all trait items implemented, missing: `new`
  --> src/main.rs:14:1
   |
2  |     fn new() -> Self;
   |     ----------------- `new` from trait
...
14 | impl Shape for Rectangle {
   | ^^^^^^^^^^^^^^^^^^^^^^^^ missing `new` in implementation

特徵定義預設行為

另一種方式是,特徵可以定義預設行為,在 trait 的區塊內寫完整的函數,那個函數就是預設行為了。

trait Shape {
    fn new() -> Self;
    
    fn area(&self) -> f64;
    
    fn perimeter(&self) -> f64;

    fn introduce(&self) {
        println!("This is a shape"); // 預設行為
    }
}

如果需要自行定義,在 impl 的區塊定義同名稱的函數就會把預設行為覆蓋過去

impl Shape for Rectangle {
    fn new() -> Self {
        Self { width: 1.0, height: 1.0 }
    }
    
    fn area(&self) -> f64 {
        self.width * self.height
    }
    
    fn perimeter(&self) -> f64 {
        2.0 * (self.width + self.height)
    }

    fn introduce(&self) {
        println!("This is a rectangle"); // 各個實作再自行定義的行為
    }
}

這樣就可以不用把所有特徵包含的函數都多實作一次,減少重複代碼,也保留了彈性可以根據需求在實作的時候再自行定義。

適合在特徵定義的函數

上面的程式還有一個地方想討論,特徵應該定義什麼樣的函數?
以目前的例子來說,建立 Rectangle 需要兩個參數: widthheight ,但是 Circle 只需要一個參數 radius ,如果我想要在建立實例的時候自訂大小,因為我把 new 放在特徵定義,有時候只要一個參數、有時候要兩個參數,實際上沒辦法改成可以自訂初始化實例大小又定義出共通參數,看來就是個不好的實踐。比較恰當的做法應該給每個型別自行定義。

trait Shape {
    fn area(&self) -> f64;
    
    fn perimeter(&self) -> f64;
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    fn new(width: f64, height: f64) -> Self {
        Self {
            width,
            height
        }
    }
}

impl Shape for Rectangle {    
    fn area(&self) -> f64 {
        self.width * self.height
    }
    
    fn perimeter(&self) -> f64 {
        2.0 * (self.width + self.height)
    }
}

struct Circle {
    radius: f64,
}

impl Circle {
    fn new(radius: f64) -> Self {
        Self {radius}
    }
}

impl Shape for Circle {    
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
    
    fn perimeter(&self) -> f64 {
        2.0 * std::f64::consts::PI * self.radius
    }
}

fn main() {
    let rect = Rectangle::new(1.5, 2.5);
    let circle = Circle::new(1.5);

    println!("矩形面積: {}, 周長: {}", rect.area(), rect.perimeter());
    println!("圓形面積: {}, 周長: {}", circle.area(), circle.perimeter());
}

這樣看起來方法應該比較容易放在特徵裡,因為可以透過 &self 一個參數取得實例上的其他屬性或方法,而且關聯性高的方法再集中到特徵,其他就各型別自行定義、實作就好,才不會因為特徵的限制反而變不彈性了。

特徵界限

如同上一篇提到,在有泛型的情況下,有時候我們想要保有泛型的彈性,但又想限制泛型的範圍的時候,就需要用到特徵。
接下來討論函數的參數要如何使用特徵來限制型別,最基本的情境可以用 impl Trait 語法,代表的是有實作某個特徵的型別,在函數本體我們就可以調用這個特徵有定義的方法。
需要特別注意impl Trait語法只能用在函數的參數和回傳值的型別詮釋

以下編譯器都會報錯:

let shape: impl Shape = Circle {
    name: "Circle".to_string(),
    radius: 2.5,
};
// `impl Trait` is not allowed in the type of variable bindings

struct MyStruct {
    shape: impl Shape
}
// `impl Trait` is not allowed in field types

正確用法:

fn print_shape_info(shape: &impl Shape) {
    println!("面積: {}, 周長: {}", shape.area(), shape.perimeter());
}

impl Shape 就是代表有實作 Shape 的型別, & 是指引用,所以這個函數不會取得傳進來的 shape 的所有權。
不過這個寫法是一種語法糖,完整的寫法就是特徵界限,上面的例子完整寫是這樣:

fn print_shape_info<T: Shape>(shape: &T) {
    println!("面積: {}, 周長: {}", shape.area(), shape.perimeter());
}

<T: Trait> 有限制一定要是特徵不能放其他型別,不然編譯器會報錯。

error[E0404]: expected trait, found struct `Circle`
  --> src/main.rs:51:24
   |
51 | fn print_shape_info<T: Circle>(shape: &T) {
   |                        ^^^^^^ not a trait

我們在泛型已經看過類似的寫法,不過那時候的再更複雜一些,現在再回頭看一下:

fn sum<T: std::ops::Add<Output = T>>(n1: T, n2: T) -> T {
    n1 + n2
}

std::ops::Add 是 Rust 標準庫中定義的一個特徵,和前面的 Shape 是一樣的,傳進來參數是同一種泛型型別 T,比較特別的應該是<Output = T>,所以我們看一下 std::ops::Add 的源碼:

pub trait Add<Rhs = Self> {
    /// The resulting type after applying the `+` operator.
    #[stable(feature = "rust1", since = "1.0.0")]
    type Output;

    /// Performs the `+` operation.
    ///
    /// # Example
    ///
    /// ```
    /// assert_eq!(12 + 1, 13);
    /// ```
    #[must_use = "this returns the result of the operation, without modifying the original"]
    #[rustc_diagnostic_item = "add"]
    #[stable(feature = "rust1", since = "1.0.0")]
    fn add(self, rhs: Rhs) -> Self::Output;
}

這是一個用到泛型的特徵,Rhs(Right Hand Side)是泛型名稱,= Self 代表預設值是實作這個特徵的型別,這樣可以讓使用者在大多數情況下不必指定+右邊的型別,不指定代表左右兩邊是同一個型別,也可以彈性在需要的時候指定右邊的型別。

特徵本體有一行 type Output; ,這種在特徵中定義的類型別名叫做關聯型別(Associated Type)。它允許我們在不使用泛型參數的情況下,為特徵引入額外的型別靈活性。

想像一下有一個特徵定義不同的方法,如果沒有關聯型別的話,要嘛我所有方法都輸出都要是一樣的,被泛型參數限制,要嘛用多個泛型參數,但那樣可讀性會變很差,泛型參數則針對這種情況提供了更精細型別選擇。
簡單來說,關聯型別是一種在特徵內部定義的、與特徵相關的類型,在實作的時候才自己定義。

再看 Add 特徵裡的方法 add ,第一個參數是 self 、第二個 rhs 要和泛型參數型別相同,分別代表 + 的左邊和右邊,如果不特別定義的話預設兩者是同一個型別,輸出則是 Self 命名空間底下的 Output 型別,會在實作特徵的時候才定下來。

我們試著幫非數字的型別實作 Add 特徵:

use std::ops::Add;

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

impl Add<i32> for Point { // + 左邊是 Point 型別,右邊是 i32
    type Output = Self; // Point 型別

    fn add(self, rhs: i32) -> Self::Output {
        Point {
            x: self.x + rhs,
            y: self.y + rhs,
        }
    }
}

fn main() {
    let point = Point { x: 1, y: 2 };
    println!("Original point: {:?}", point); // Original point: Point { x: 1, y: 2 }
    let modified_point = point + 1;
    println!("Modified point: {:?}", modified_point); // Modified point: Point { x: 2, y: 3 }
}

我定義了 Point 是一個座標型別,為了讓它使用 + 實作 Add 特徵,型別限定為 i32 和座標的 xy 屬性相同方便計算,方法 add 定義+右邊的型別是特徵的泛型參數型別 i32,回傳另外一個座標位置是把實體上的 xy 數值分別加上+右邊的數值。
這是 Output 和實體是同一種型別的情況。

也可以讓 Output 的型別和+左右邊都不同,例如把座標的 xy 數值加上加號右邊的數值而且回傳的型別是 f64

impl Add<i32> for Point {
    type Output = f64;

    fn add(self, rhs: i32) -> Self::Output {
        (self.x + self.y + rhs) as f64
    }
}

fn main() {
    let point = Point { x: 1, y: 2 };
    println!("Original point: {:?}", point);
    let result = point + 1;
    println!("Add result: {}", result); // Add result: 4.0
}

多個特徵界限條件

回到特徵界限,現在已經理解當初寫成 fn sum<T: std::ops::Add<Output = T>>(n1: T, n2: T) -> T 怎麼限制範圍在只有有實作特定特徵的型別。
另外也可以透過 + 來指定不只一個特徵界限,例如當初這段:

// 針對有正負號的數字型別的特殊方法
impl<T: std::ops::Neg<Output = T> + std::ops::Add<Output = T> + std::ops::Mul<Output = T> + Copy> Point<T> {
    // 以 x 軸為對稱軸的對稱點
    fn symmetric_x(&self) -> Self {
        Point { x: self.x, y: -self.y }
    }

    // 以 y 軸為對稱軸的對稱點
    fn symmetric_y(&self) -> Self {
        Point { x: -self.x, y: self.y }
    }
}

我們就限制了型別必須要同時符合實作了 NegAddMulCopy 這些特徵的型別,而且他們的 Output型別都被限制成同一種 T

我們可以再多一個泛型參數 U 代表擁有不同特徵的型別:

use std::ops::Add;

fn add_numbers<T: Into<U>, U: Add<Output = U>>(a: T, b: U) -> U
{
    a.into() + b
}

fn main() {
    let result: f64 = add_numbers(1, 0.3);
    println!("{}", result); // 1.3
}

where 子句

不過條件越來越多可讀性會變差,所以 Rust 有提供另一個在函數簽名之後指定特徵界限的語法 where 子句。
改寫完之後結果如下:

fn add_numbers<T, U>(a: T, b: U) -> U
where
    T: Into<U>,
    U: Add<Output = U>,
{
    a.into() + b
}

如果有多個泛型參數又都不只一個特徵界限,這種寫法就更好讀。

回傳特徵型別

Rust 除了能在輸入的地方用impl Trait 語法代表具有某種特徵的型別,也可以在回傳值的地方用來代表回傳的型別具有某種特徵。

例如設計一個函數會回傳擁有 Shape 特徵的實體。

fn get_either_shape(switch: bool) -> impl Shape {
    Rectangle {
        name: String::from("rectangle"),
        width: 1.0,
        height: 1.0
    }
}

這樣可以正常編譯沒問題,不過這樣沒有發揮 impl Shape 的優勢,所以想讓它可以回傳不同的型別:

fn get_either_shape(switch: bool) -> impl Shape {
    if switch {
        Rectangle {
            name: String::from("rectangle"),
            width: 1.0,
            height: 1.0
        }
    } else {
        Circle {
            name: String::from("circle"),
            radius: 1.0
        }
    }
}

不過改成這樣就有問題了:

error[E0308]: `if` and `else` have incompatible types
   --> src/main.rs:109:9
    |
102 | /       if switch {
103 | | /         Rectangle {
104 | | |             name: String::from("rectangle"),
105 | | |             width: 1.0,
106 | | |             height: 1.0
107 | | |         }
    | | |_________- expected because of this
108 | |       } else {
109 | | /         Circle {
110 | | |             name: String::from("circle"),
111 | | |             radius: 1.0
112 | | |         }
    | | |_________^ expected `Rectangle`, found `Circle`
113 | |       }
    | |_______- `if` and `else` have incompatible types
    |
help: you could change the return type to be a boxed trait object
    |
101 | fn get_either_shape(switch: bool) -> Box<dyn Shape> {
    |                                      ~~~~~~~      +
help: if you change the return type to expect trait objects, box the returned expressions
    |
103 ~         Box::new(Rectangle {
104 |             name: String::from("rectangle"),
105 |             width: 1.0,
106 |             height: 1.0
107 ~         })
108 |     } else {
109 ~         Box::new(Circle {
110 |             name: String::from("circle"),
111 |             radius: 1.0
112 ~         })
    |

原因是當使用 impl Trait 作為回傳型別時,編譯器需要在編譯時確定具體回傳的型別,而即使RectangleCircle 有相同特徵,但它們還是不同的型別、有不同的結構,這種情況可以用 Box<T>:一種智慧指標來處理,之後在智慧指標的介紹再來探討其中機制。

結語

特徵與特徵界限和泛型的搭配可以保留泛型彈性的同時,又可以提早在編譯期發現型別錯誤,讓程式更穩定(降低在執行找不到對應方法報錯造成程式崩潰),提升程式執行效率(因為執行的時候不用再檢查一次有沒有這個方法),提供一個可以精確地指定型別需要滿足的條件的機制,既不會過於限制,也不會過於寬鬆,讓我們能保留彈性的同時寫出更安全、更高效的程式碼。


上一篇
Day20 - 泛型
下一篇
Day22 - 常見集合:向量
系列文
螃蟹幼幼班:Rust 入門指南25
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言